iT邦幫忙

2024 iThome 鐵人賽

DAY 22
0
自我挑戰組

30 天 vueuse 原始碼閱讀與實作系列 第 22

[Day 22] useScroll - scrolling

  • 分享至 

  • xImage
  •  

官方 Demo:https://vueuse.org/core/useScroll/#usescroll

昨天看了 Demo 中的 Top ArrivedRight ArrivedBottom ArrivedLeft Arrived 是怎麼來的,接下來繼續看 Demo 中 Scrolling UpScrolling RightScrolling DownScrolling LeftisScrolling 這五個 Boolean 的計算方式。

directions

Scrolling UpScrolling RightScrolling DownScrolling Left 這四個 Boolean 是來自於 directions 這個 reactive 物件。

以下只保留跟這次主題 directions 有關的程式碼:

// src/compositions/useScroll.js

export function useScroll(element, options = {}) {
 const {
    throttle = 0,
    idle = 200,
    onStop = noop,
    eventListenerOptions = {
      capture: false,
      passive: true,
    },
  } = options
  
  const isScrolling = ref(false)
  
  const directions = reactive({
    left: false,
    right: false,
    top: false,
    bottom: false,
  })
  
  const onScrollEnd = (e) => {
    // dedupe if support native scrollend event
    if (!isScrolling.value)
      return

    isScrolling.value = false
    directions.left = false
    directions.right = false
    directions.top = false
    directions.bottom = false
    onStop(e)
  }
  
  const setArrivedState = (target) => {
    if (!window)
      return

    const el = (
      (target)?.document?.documentElement
      || (target)?.documentElement
      || unrefElement(target)
    )

    const scrollLeft = el.scrollLeft

    directions.left = scrollLeft < internalX.value
    directions.right = scrollLeft > internalX.value
    
    internalX.value = scrollLeft
    
    // ...略
    
    let scrollTop = el.scrollTop

    directions.top = scrollTop < internalY.value
    directions.bottom = scrollTop > internalY.value
    
    internalY.value = scrollTop
  }
  
  const onScrollHandler = (e) => {
    // ...略
    const eventTarget = (
      (e.target).documentElement ?? e.target

    isScrolling.value = true
    setArrivedState(eventTarget)
    onScrollEndDebounced(e)
  }
  
  useEventListener(
    element,
    'scroll',
    onScrollHandler,
    eventListenerOptions,
  )
  
  useEventListener(
    element,
    'scrollend',
    onScrollEnd,
    eventListenerOptions,
  )
  
  return {
    isScrolling,
    directions,
  }
}

可以看到主要邏輯跟前兩天一樣,也是放在 setArrivedState function:

directions.left = scrollLeft < internalX.value
directions.right = scrollLeft > internalX.value

internalX.value = scrollLeft

這邊先拿 directions.left 來看,向左滾動的時候,scrollLeft 會越來越小,internalX 也會跟著變小,可以在 internalX.value 更新之前,判斷 scrollLeft 比還沒更新的 internalX 還小,代表正在向左滾動。directions.right 也是一樣的邏輯。

接下來要考慮的就是,停止向左滾動的時候,directions.left 必須被設定成 false,這邊有用到 scrollend 這個事件偵聽,當 scrollend 觸發時,會執行 onScrollEnd function:

const onScrollEnd = (e) => {
    // dedupe if support native scrollend event
    if (!isScrolling.value)
      return

    isScrolling.value = false
    directions.left = false
    directions.right = false
    directions.top = false
    directions.bottom = false
    // 上層傳入的參數,在 onScrollEnd 執行時會呼叫
    onStop(e)
}

滿直覺的,就是在 scrollend 的時候把所有 scrolling 相關 boolean 都設定成 fasle。
需要注意的是這段:

// dedupe if support native scrollend event
if (!isScrolling.value)
  return

如果回到最上面程式碼看的話,會看到 onScrollEnd function 其實有兩個地方會呼叫到他,一個是我們現在提到的 scrollend event 被觸發的時候,另一個則是在 onScrollHandler 內部呼叫 onScrollEndDebounced(e)。先來說為什麼都已經透過監聽 scrollend 事件的觸發,來呼叫 onScrollEnd 了,還需要 onScrollEndDebounced

因為 safari 目前還沒有支援 scrollend event。來看一下 onScrollEndDebounced

const onScrollEndDebounced = useDebounceFn(onScrollEnd, throttle + idle)

throttleidle 都是上層傳入的參數,idle 預設為 200ms,throttle 預設為 0,當我們滾動停止過 200ms 後,onScrollEnd 就會被執行,如果在有支援 scrollend event 的瀏覽器,會在這個 200ms 之前就已經執行過 onScrollEnd function,isScrolling.value 也已經被設定為 false 了,所以才需要有 if (!isScrolling.value) return 這層判斷,避免在 onScrollEndDebounced 這邊又重複執行一次。

safari 支援度:https://caniuse.com/?search=scrollEnd

setArrivedState 的執行時機

目前講到的 setArrivedState 的執行時機都是 scroll event 被觸發的時候,接下來要補上其他需要執行 setArrivedState 的情境,一個是 bug、另一個是 feature。

arrivedState bug

先講 bug,來看一下 arrivedState 的預設值:

const arrivedState = reactive({
    left: true,
    right: false,
    top: true,
    bottom: false,
})

預設 rightbottom 都是 false,在 element 可以滾動的情境這樣沒什麼問題,但如果 element 不需要滾動呢?在不需要滾動的情境(內層寬高比外層設定 overflow-scroll 的寬高還小),arrivedState 中的上右下左應該都要為 true,但因為剛剛提到我們的核心計算 setArrivedState 在 scoll 的時候才會執行,所以組件掛載完成後,rightbottom 依舊是 false。
知道問題在哪後,解法應該也很直覺,就是在 mounted 的時候執行一次 setArrivedState

// src/compositions/useScroll.js
tryOnMounted(() => {
    try {
      const _element = toValue(element)
      if (!_element)
        return
      setArrivedState(_element)
    }
    catch (e) {
      onError(e)
    }
})

onError 是 useScroll 的一個 option 參數,預設為 onError = (e) => { console.error(e) },接著來看一下 tryOnMounted 的程式碼:

// src/utils/shared.js
// import { getCurrentInstance, nextTick, onMounted } from 'vue'

export function getLifeCycleTarget(target) {
  return target || getCurrentInstance()
}

export function tryOnMounted(fn, sync = true, target) {
  const instance = getLifeCycleTarget()
  if (instance)
    onMounted(fn, target)
  else if (sync)
    fn()
  else
    nextTick(fn)
}

這邊有用到 Day 14 有提到過的 getCurrentInstance,詳細可以參考那篇,以目前這邊的流程,會走到 onMounted(fn, target) 這行,target 會是 undefined,在 Day 14 也有提到 Vue onMounted 的運作流程,以及在第二個參數(target)沒有值的時候,是怎麼拿到當前組件實例的,這部分就先略過。這裡可以先簡單想成在組件 mounted 的時候,執行了 setArrivedState 來更新正確的 arrivedState 狀態。

return measure

看原始碼會發現 useScroll 的 return 物件中有一個 measure 屬性:

return {
    // ...略
    measure() {
      const _element = toValue(element)

      if (window && _element)
        setArrivedState(_element)
    },
}

現在會看到 useScroll API 是因為一開始想看 useInfiniteScroll API,useInfiniteScroll API 中有用到 useScroll,造就了現在的惡夢(?)

現在我們知道 setArrivedState 會在 scroll 或是 monted 的時候被執行,這個 measure 是讓上層可以在這兩個之外的情境來執行 setArrivedState,取得最新狀態,像是 useInfiniteScroll 中的其中一段:

watch(
    () => [state.arrivedState[direction], isElementVisible.value],
    checkAndLoad,
    { immediate: true },
)

這個 checkAndLoad 裡面有執行到 setArrivedState,也就是說在 isElementVisible.value 變化的時候,需要執行 setArrivedState,至於詳細目的是什麼?當然是之後再說 XD

GitHub:https://github.com/RhinoLee/30days_vue/pull/21/files


useScroll 這條牙膏到今天第三天終於擠完了(?)看完覺得能有 vueuse 這樣的工具可以用滿幸福的,裡面有一些例外處理或是瀏覽器不同造成的差異都有考慮到,也可以從這次看原始碼的經驗,多注意之後在專案中設計共用 API 需要考慮的項目與細節。

今天就到這邊告一段落,明天開始會繼續看 useElementVisibility API~


上一篇
[Day 21] useScroll - arrivedState
下一篇
[Day 23] useIntersectionObserver
系列文
30 天 vueuse 原始碼閱讀與實作30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言